In [ ]:
import pylab as pl
%matplotlib inline
Several types of calibrations are supported. The simplest calibration assumes that frequency response is "flat". In other words, if you send a 1V RMS tone to the speaker, it will always produce the same output level regardless of frequency. You can also have calibrations that compensate for variations in speaker output as a function of frequency.
For simplicity, let's assume our speaker's response is flat. First, import the calibration class that supports flat calibrations.
In [ ]:
from psi.controller.calibration.api import FlatCalibration
Now, let's assume that when we play a 1V RMS tone through the speaker, it produces an output of 114 dB SPL. Sensitivity of acoustic systems are almost always reported in volts per Pascal. 114 dB SPL is 10 Pascals. This means that the sensitivity of the speaker is 0.1 Volt per Pascal.
By design, the sensitivity must be converted to dB(volts/Pascal) when initializing the calibration. This translates to a value of -20 dB(V/Pa) for a sensitivity of 0.1 V/Pa.
In [ ]:
calibration = FlatCalibration(-20)
One Pascal is 94 dB. Let's see if this works. The method, Calibration.get_sf gives us the RMS amplitude of the waveform needed to generate a tone at the given frequency and level. We would expect the RMS value to be 0.1.
In [ ]:
rms_amplitude = calibration.get_sf(frequency=1000, spl=94)
print(rms_amplitude)
Remember that 6 dB translates to half on a linear scale. Let's confirm this works.
In [ ]:
rms_amplitude = calibration.get_sf(frequency=1000, spl=94-6)
print(rms_amplitude)
In [ ]:
from psi.token.primitives import ToneFactory
tone = ToneFactory(fs=100000, frequency=1000, level=80, calibration=calibration)
Note that we had to provide the sampling frequency the tone must be generated at along with other stimulus parameters.
The instance supports several methods that are used by psiexperiment to properly handle the tone. For example, we need to know how long the stimulus is.
In [ ]:
tone.get_duration()
This means the tone can run continuously for the full duration of the experiment. You may use a continuous waveform (e.g., bandlimited noise) for generating a background masker.
Let's get the first 5000 samples of the tone.
In [ ]:
waveform = tone.next(5000)
pl.plot(waveform)
Let's get the next 1000 samples.
In [ ]:
waveform = tone.next(1000)
pl.plot(waveform)
As you can see, a factory supports incremential generation of waveforms. This enables us to generate infinitely long waveforms (such as maskers) that never repeat.
Tones are boring. Let's look at a more interesting type of stimulus. Sinusoidally-amplitude modulated noise with a cosine-squared onset/offset ramp.
In [ ]:
from psi.token.primitives import Cos2EnvelopeFactory
import importlib
import enaml
from psi.token import primitives
with enaml.imports():
importlib.reload(primitives)
waveform = tone.next(5000)
#pl.plot(waveform)
pl.psd(waveform, Fs=200e3);
In [ ]:
import numpy as np
t = (np.arange(500.0) + 0) / 50000.0
y = 1 * np.sin(2 * np.pi * t * 16520 + 0)
pl.plot(t, y)
In [ ]:
tone = primitives.ToneFactory(fs=200e3, frequency=16000, level=94, calibration=calibration)
envelope = Cos2EnvelopeFactory(fs=100000, start_time=0, rise_time=5e-3, duration=10, input_factory=tone)
waveform = envelope.next(1000)
pl.figure()
pl.plot(waveform)
waveform = envelope.next(1000)
pl.figure()
pl.plot(waveform)
pl.figure()
pl.specgram(waveform, Fs=100000);
In [ ]:
from psi.token.primitives import BandlimitedNoiseFactory, SAMEnvelopeFactory
In [ ]:
noise = BandlimitedNoiseFactory(fs=100000, seed=0, level=94, fl=2000,
fh=8000, filter_rolloff=6,
passband_attenuation=1,
stopband_attenuation=80,
equalize=False, calibration=calibration)
Like tone factories, the bandlimited noise factory can run forever if you want it to.
In [ ]:
noise.get_duration()
In [ ]:
waveform = noise.next(5000)
pl.plot(waveform)
Now, let's embed the noise in a sinusoidally amplitude-modulated (SAM) envelope. Note that when we create this factory, we provide the noise we created as an argument to the parameter input_waveform.
In [ ]:
sam_envelope = SAMEnvelopeFactory(fs=100000, depth=1, fm=5,
delay=1, direction=1,
calibration=calibration,
input_factory=noise)
In [ ]:
sam_envelope.get_duration()
In [ ]:
waveform = sam_envelope.next(100000*2)
pl.plot(waveform)
Now, embed the SAM noise inside a cosine-squared envelope.
In [ ]:
cos_envelope = Cos2EnvelopeFactory(fs=100000, start_time=0,
rise_time=0.25, duration=4,
input_factory=sam_envelope)
In [ ]:
cos_envelope.get_duration()
By definition, a cosine-squared envelope has a finite duration. Let's plot the first two seconds.
In [ ]:
waveform = cos_envelope.next(100000*2)
pl.plot(waveform)
Now, the next two seconds.
In [ ]:
waveform = cos_envelope.next(100000*2)
pl.plot(waveform)
What happens if we keep going? Remember the duration of the stimulus is only 4 seconds.
In [ ]:
waveform = cos_envelope.next(100000*2)
pl.plot(waveform)
That's because the stimulus is over. We can check that this is the case.
In [ ]:
cos_envelope.is_complete()
What if we want to start over at the beginning? Reset it.
In [ ]:
cos_envelope.reset()
waveform = cos_envelope.next(100000*4)
pl.plot(waveform)
In [ ]:
from psi.token.primitives import ChirpFactory
chirp = ChirpFactory(fs=100000, start_frequency=50, end_frequency=5000,
duration=1, level=94, calibration=calibration)
waveform = chirp.next(5000)
pl.plot(waveform)
In [ ]:
chirp.get_duration()
You would subclass psi.token.primitives.Waveform and implement the following methods:
__init__: Where you perform potentially expensive computations (such as the filter coefficients for bandlimited noise)reset: Where you reset any settings that are releavant to incremential generation of the waveform (e.g., the initial state of the filter and the random number generator for bandlimited noise).next: Where you actually generate the waveform.get_duration: The duration of the waveform. Return np.inf if continuous.Want to look at an example of a relatively complex waveform? See bandlimited noise or chirps.
We use Enaml, which is a superset of Python's syntax to create a plugin-oriented system. When running psiexperiment in workbench mode, you have a number of plugins that you can contribute to. One plugin is psi.context.items. All items contributed to this plugin will appear in the GUI.
You can contribute new context items to this plugin by defining a manifest. A manifest looks like this.
Note a few things:
The details of how a particular waveform's parameters actually appear in the context registry are a bit complex (and can likely be simplified once I have the time). So, let's illustrate a simpler example of how the context plugin works.
In [ ]:
from enaml.workbench.api import Workbench
from psi.controller.output import ContinuousOutput, EpochOutput
from psi.token.primitives import BandlimitedNoise
import enaml
with enaml.imports():
from simple_manifest import SimpleManifest
from psi.context.manifest import ContextManifest
In [ ]:
workbench = Workbench()
workbench.register(ContextManifest())
context_plugin = workbench.get_plugin('psi.context')
context_plugin.get_context_info()
In [ ]:
workbench.register(SimpleManifest())
context_plugin.get_context_info()
In [ ]:
c_output = ContinuousOutput(name='masker')
c_output.token = BandlimitedNoise()
c_output.load_manifest(workbench)
In [ ]:
ci = context_plugin.get_context_info()
for k in ci.keys():
print(k)
In [ ]:
context_plugin.apply_changes()
context_plugin.get_values()
In [ ]: